Skip to main content

Medications Overview

The Patient Portal Medications API lets the authenticated patient list the medications prescribed on their own cases. The endpoint is self-only: the JWT subject is the only patient whose records are returned, scoped to the cases owned by that patient in the calling organization.

Endpoints

#MethodPathPurpose
1GET/api/v1/users/me/medicationsList the patient's prescribed medications (optionally filtered by case)

Related resources: Orders (/me/orders) and Payments (/me/payments).

Authentication

Every endpoint requires a successful /verify-otp exchange first.

HeaderRequiredDescription
cv-api-keyYesTenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR.
AuthorizationYesBearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401.

The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".

Permission Matrix

ActionAllowed when…
List own medicationsAlways (filtered to the patient's cases in the calling org).
List a specific case's medicationsThe case is owned by the patient (submitterId) and belongs to the calling org. Otherwise → 403.

When caseId is omitted the server resolves the patient's own case ids in the calling organization first; if the patient has no cases, the response data array is [] and nextCursor is null.

Response Envelope

The list is wrapped under medications alongside the pagination cursor:

{
"status": 200,
"success": true,
"data": {
"medications": [ "..." ],
"nextCursor": "<id> | null"
}
}

Error responses follow:

{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }

Query Parameters

FieldTypeRequiredNotes
caseIdstring (UUID)NoRestrict the list to a single case owned by the patient. Verified via ensurePatientOwnsCase — if the case does not belong to the patient or to the calling org, returns 403.
limitintegerNo1100. Defaults to 20. Coerced from string.
afterstring (UUID)NoCursor — the last id from the previous page. The server skips that row and returns the next page.

Pagination

Cursor-based over the row id, ordered by the parent decision's createdAt descending, then id ascending:

  1. Request page 1 without after. The server returns up to limit items plus nextCursor.
  2. If nextCursor is non-null, pass it as after=<nextCursor> to fetch the next page.
  3. When the server has no more rows, nextCursor is null.

The cursor is the last item's id (a CaseDecisionMedInfo.id). Internally the server takes limit + 1 rows, drops the extra, and emits its id as the cursor — so a null cursor unambiguously means "no more pages."

Object Shapes

Medication

Returned by GET /me/medications. One row per CaseDecisionMedInfo — a single decision with multiple meds yields multiple rows.

FieldTypeNotes
idstring (UUID)CaseDecisionMedInfo.id. Use this value in after for pagination.
medicinestringDrug name.
dosagestring | nulle.g. "500 mg".
refillCountnumber | nullNumber of refills authorized.
dosingFrequencystring | nulle.g. "twice daily".
treatmentPeriodstring | nullFree-text duration.
isRefillboolean | nullWhether this entry represents a refill.
pharmacyInstructionsstring | nullFree-text instructions for the pharmacy.
prescribedDateISO-8601 datetime | nullCaseDecision.startDate.
caseIdstring (UUID)The case this prescription belongs to.
isApprovedbooleanWhether the underlying decision is approved.
prescriber{ id, firstName, lastName, title } | nullThe User who authored the decision (CaseDecision.addedBy).

Server-Side Behaviors and Defaults

  • Tenant + ownership scoping. With or without caseId, the result is restricted to cases where submitterId = userId and organizationId = req.patientOrganization.id. There is no cross-tenant or cross-patient surface.
  • caseId is pre-validated. When supplied, ensurePatientOwnsCase runs before the list query; failure short-circuits to 403.
  • No row-level soft delete. Medication rows have no isDeleted flag; every medInfo row on an in-scope decision is returned.
  • Default limit. 20. Maximum 100. The validator coerces string → number.
  • Cursor semantics. after is the last row's id from the previous page; the server uses cursor: { id: after }, skip: 1 and asks for limit + 1 rows to detect end-of-results.
  • Empty patient. If the patient has no cases in the calling org (and no caseId was supplied), the endpoint returns { medications: [], nextCursor: null } — no error.

Security Properties

  • Tenant isolation. Case-id resolution pins organizationId to the calling org from cv-api-key; the medication query is filtered to the case ids that resolution returns.
  • Ownership isolation. Case-id resolution pins submitterId to the JWT subject; caseId queries additionally pass through ensurePatientOwnsCase.
  • Uniform 403. "Doesn't exist", "not yours", and "wrong tenant" all collapse to the same 403 VALIDATION_ERROR "You do not have access to this case" so case ids cannot be probed.
  • Token type pinned. Only JWTs with type: 'patient-portal' reach the handler.
  • Cross-tenant defense. The JWT's organizationId is verified against the cv-api-key-resolved org on every call.
  • No write surface. The endpoint is read-only.

Integrator Guidance

  • Refresh proactively. Refresh the access token via /refresh-token before the 15-minute expiry.
  • Listing strategy. Use ?caseId= when surfacing medications within a single-case view; omit it for an account-wide list.
  • Paginate forward only. The cursor moves forward through the sort order — there is no before cursor.
  • Prefer this endpoint for medication detail. The medications embedded in an Order (order.medications[]) carry only medicine / dosage / dosingFrequency; prescriber, refillCount, isApproved, and the rest are only here.
  • Expect multiple rows per decision. One prescription decision with three drugs returns three rows sharing the same caseId and prescribedDate.
  • Treat 403 as "no access, may or may not exist". Do not display case-id-specific debug text.